Decoration for polar panels#418
Conversation
Replace CoordKind match arms throughout the Vega-Lite writer with a ProjectionRenderer trait. Each projection type (cartesian, polar) now owns its channel mapping and spec transformation logic, making it straightforward to add map projections in the future. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reduces boilerplate at call sites that use default aesthetics and empty properties. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
background_layers() and foreground_layers() let projections prepend/append VL layers around the data layers (e.g. grid lines, axis ticks). Both receive resolved scales and the theme config so implementations can derive decoration from break positions and style tokens. Also moves apply_project_transforms and apply_panel_decor from free functions into default methods on the trait (apply_transforms, apply_panel_decor), removing the redundant renderer construction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Decoration layers inserted by apply_panel_decor() now get "description": "background" or "foreground" automatically. Tests use a new data_layer() helper that filters these out by index, so they remain stable regardless of whether a projection adds decoration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-arc marks in polar projection (point, line) now compute x/y in the same pixel coordinate space arc marks use: center at (width/2, height/2) with outerRadius = min(width,height)/2. Encodings use scale:null so Vega-Lite treats values as raw positions. Also filters null position values via isValid(), since scale:null bypasses VL's implicit null handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extracts five VL expression builders (expr_normalize_radius, expr_normalize_theta, expr_polar_x, expr_polar_y, expr_polar_radius) that are now used by data-layer transforms, arc mark radius ranges, and decoration layers. Introduces POLAR_OUTER const for the normalised outer radius. Also extracts polar_properties() from the inline parsing that was duplicated in apply_polar_project. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements background decoration layers for polar projections: a filled panel arc, concentric grid rings at radius breaks, and radial grid spokes at theta breaks. Moves numeric break/domain extraction to Scale methods for reuse across the codebase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Draws axis line, tick marks, and labels along the start angle for the radius (pos1) scale. Ticks are centered on full circles and extend outward on partial arcs. Fixes operator precedence in expr_polar_x/y by parenthesising the radius expression. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Draws axis arc along the outer edge, radial tick marks at each theta break, and centered text labels beyond the ticks. Label alignment uses center/middle for now — per-datum alignment needs a different approach in Vega-Lite. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ment Vega-Lite text marks only support a single align/baseline per layer. Bucket breaks by their computed (align, baseline) in Rust, tag each data row with an _ab field, and emit a sub-layer per unique tag that filters on it and sets the correct mark properties. Also fix clippy warnings (unused variable, unused import, unused mut). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces separate apply_transforms() and apply_panel_decor() calls with one apply_projection() method. Moves faceting before projection so decoration layers work correctly in faceted specs. Renames the unclear apply() to transform_layers(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pers PolarProjection now holds a PolarPanel with pre-computed angular range, radius bounds, and VL expression strings (signal-based for non-faceted, literal pixels for faceted). Expression helpers are methods on PolarPanel, replacing the free functions. All private methods read from self.panel instead of taking Projection parameters. Also adds is_faceted() to the ProjectionRenderer trait with a default panel_size() that returns container sizing, letting the call site in mod.rs delegate sizing entirely to the projection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Discrete and ordinal scales now synthesize numeric positions from their categorical input ranges (breaks [1..n], domain [0.5, n+0.5]) so that polar grid decoration can work uniformly across all scale types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tesian conversion Extends convert_polar_to_cartesian for non-arc polar marks: - Discrete theta/radius domains generate indexof() VL expressions - radius2/theta2 channels converted to x2/y2 using primary domain - Offset channels (radiusOffset/thetaOffset) normalized into polar space when they carry a scale domain, or applied as raw pixel displacements along the radial/tangential directions otherwise - Discrete offsets narrowed by band fraction (0.9) to leave angular gaps between adjacent categories, matching VL band scale padding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds break_labels() to ScaleTypeTrait, returning (position, label) pairs. Discrete and ordinal scales pair integer positions with input-range category names; continuous scales format break values as strings. Scale.break_labels() applies label_mapping overrides on top (renaming or suppressing to empty string). Polar radial and angular axes now use break_labels() so discrete categories show their names instead of numeric positions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
indexof() returns -1 for values not in the domain array, which previously produced position 0 (outside the synthesized domain). Now maps to null so VL drops the row. Also escapes single quotes in category names to prevent broken VL expressions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Polar grid lines and axes are drawn as manual VL layers positioned from the global scale domain. With free scales each panel has its own domain, so the global positions would be wrong. Suppress them rather than render misleading decorations. Also adds Facet::is_free() and removes the free_scales plumbing from EncodingContext/ScaleContext in favour of reading spec.facet directly. get_projection_renderer() now takes Option<&Facet> instead of a bool. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Nullable boolean `radar` setting on PROJECT TO polar. When null (default), auto-detects from theta scale discreteness. When explicitly true, validates that the angle scale is discrete. Resolved after scale resolution in resolve_projection_properties() so downstream code can read it as a plain boolean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When radar mode is active (discrete theta), panel background, grid rings, and angular axis outline use straight-segment polygons instead of circular arcs. Shared helpers: arc_ring, polygon_ring, theta_breaks. Donut panels (inner > 0) trace outer vertices forward then inner reversed so the fill rule leaves the centre hole empty. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
For partial circles (start != end - 360), polygon_ring now adds vertices at the start and end angles and traces back through the centre (or inner radius) to form a closed wedge shape. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The start angle bisects a polygon face, which sits at cos(half_span) of the circumscribed radius. Scale the axis line, ticks, and labels inward so they land on the polygon edge rather than beyond it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In radar mode, theta offsets (e.g. jitter) now interpolate linearly toward the adjacent spoke instead of following a circular arc. This keeps displaced points inside the polygon panel boundaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Partial-arc start/end vertices are now pulled inward by cos(angle_to_nearest_break) so boundary faces are flush with inter-break edges. The radial axis correction is extended from full-circle-only to all radar panels. Theta offset lerp targets are clamped to [start, end] so boundary spokes lerp toward the panel edge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Radar plots with only 1–2 categories degenerate into a line or single axis, so suppress auto-detection and reject explicit `radar => true` when the theta scale has ≤2 levels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce AxisInfo (domain, breaks, labels, is_free) built from scales at construction time. Zero-range domains normalize to None upfront, eliminating downstream guards. Add is_full_circle and angle_breaks_radians as derived fields on PolarContext. This simplifies expr_normalize_radius/theta, all decoration methods (grid_rings, grid_spokes, radial_axis, angular_axis, panel_arc), convert_polar_to_cartesian, and polygon_ring — which no longer need scale/domain/thetas parameters passed through call chains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The polar projection never transforms the DataFrame — it only modifies the VL spec. Drop the data parameter and Option<DataFrame> return from transform_layers and apply_projection. Also fix 7 unnecessary mut bindings in tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
encoding.rs escaped single quotes but not backslashes in label remap expressions. Consolidate all three call sites to use the existing escape_vega_string helper, which handles both. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract categorical_numeric_breaks, categorical_numeric_domain, and categorical_break_labels into scale_type/mod.rs. Both Discrete and Ordinal now delegate to these instead of duplicating the logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
thomasp85
left a comment
There was a problem hiding this comment.
Generally looks good. Remember a CHANGELOG note and perhaps also add an end-to-end test for the decoration
| vertices.push((outer_radius, theta)); | ||
| } | ||
| if let Some(&first) = thetas.first() { | ||
| vertices.push((outer_radius, first)); |
There was a problem hiding this comment.
is this necessary? doesn't it close itself?
I often find these types of innocuous repeated vertices to be the source of visual artefacts so I'm on the lookout :-)
There was a problem hiding this comment.
It doesn't close itself automatically. We're using this trick for layer polygons, but that solution doesn't transfer cleanly here.
When it is a radar-donut, we need to close it explicitly anyway. In the image below, I've numbered the polygon vertices in order we're drawing them. We're talking about vertex 6. If we don't have 6, the polygon will go 5 -> 7 creating a diagonal seam, which we don't want.
There was a problem hiding this comment.
oh - it's not a polygon with a hole? it's a self-intersecting polygon?
There was a problem hiding this comment.
There is no holed-polygon in vegalite AFAICT. I don't know if it counts as self-intersecting if the 6-7 and 12-1 edges are collinear.
This PR aims to fix #156.
In addition to putting panel background, gridlines and axes for angle and radius aesthetics, it also introduces the
radar = <null/true/false>setting.The decoration is not complete, and in particular misses the
FACET ... SETTING free => 'angle'/'radius'case. Vegalite's infrastructure around scale sharing between panels (or lack thereof) does not help us at all in the polar case. I suggest we should fix the cartesian cases first before we demand parity for polar coordinates.There are some crude solutions around the use of the theme, which we can refine once we actually have a proper theme system in place.
This is a large PR on the one hand because we cannot use pre-existing infrastructure from vegalite, so we have to artisinally draw everything ourselves. On the other hand, this also pioneers some ProjectionRenderer abstractions that I hope will pay off later when implementing spatial projections.